Skip to content

fix(app-shell): a failed managed-snapshot refresh is not a current record (objectui#7907) - #7937

Merged
os-sam merged 1 commit into
mainfrom
claude/issue-7907-manage-snapshot-refresh-swallows
Sep 6, 2026
Merged

fix(app-shell): a failed managed-snapshot refresh is not a current record (objectui#7907)#7937
os-sam merged 1 commit into
mainfrom
claude/issue-7907-manage-snapshot-refresh-swallows

Conversation

@claude

@claude claude Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Fixes #7907

The defect

The tail of onManageChanged — the managed-snapshot refresh that runs after every
lifecycle action fired from the PackageDetailSheet (disable / enable / duplicate /
publish / publish-drafts / manifest edit) — was:

try {
  const fresh = await fetchFullPackage(managedId);
  if (fresh) setManage(fresh);
} catch {
  /* keep the current snapshot */
}

The comment is true of what it did, and it is the reason it was wrong. The record in
manage is already known to be out of date at that point: the action the author
just fired is what changed it, and this read is what replaces it. So the author disabled
a package, was told nothing, and went on reading Status: Enabled — the pre-action
record presented as current.

#7881 did not introduce this — it made this arm swallow MORE

Fixing one swallowed-error defect makes another swallowing site swallow more. Before
objectui#7881 (PR #7906) fetchFullPackage never read res.ok, so this catch could
only ever see a res.json() rejection — a non-JSON body. Now that the helper refuses a
non-2xx, the same catch also swallowed every 401 / 403 / 503 / 500 the endpoint
serves. ⛔ Not a regression from #7906: a pre-existing defect that #7906 made much
easier to hit. A reader who meets this card without that sentence will conclude the
opposite.

The response shapes, measured on THIS arm

⛔ Not taken from #7881 on trust — re-measured at the producer, because this arm reads
fetchFullPackage and could have taken a different path. It does not: it is
GET /api/v1/packages, served by the direct-mount registrar
(@objectstack/rest package-routes.ts). Its list handler contains no hand-written
body — success leaves through sendOk, every failure through sendError /
sendThrownError, and sendError writes exactly
{ success: false, error: { code, message, ...extra } }.

shape how it reaches this arm covered by this PR
401 UNAUTHENTICATED the anonymous-deny floor yes — §2.2
403 FORBIDDEN the studio.access / setup.access capability gate yes — §2.1
503 SERVICE_UNAVAILABLE either half of the two-source merge refusing a read it could not perform yes — §1
500 INTERNAL_ERROR a fault; prose withheld for the generic sentence, so error.code is the only discriminating word yes — same arm as §1
non-JSON error body a proxy's HTML 502/504; res.json() rejects yes — §3, names the status
no response at all fetch itself rejects (offline / DNS / reset) — there is no res.ok to read and no envelope to quote yes — §4
a successful read, package absent fresh === null; NOT a throw yes — §5

Two of those are additions to #7881's table rather than restatements of it, and both are
specific to this arm: it runs immediately after a mutating request, which is when a
transport drop is most likely, and it is reached with a package id that a concurrent
delete can have removed.

The 400 VALIDATION_ERROR of the sibling /packages/:id routes is not reachable
here: it comes from repeated query parameters, and this call sends no query string.

One shape measured and NOT covered, reported as asked. Since the framework's #12502
this door also emits error.userMessage — text a producer marked at throw time as
addressed to the end user, which ADR-0112 tells a consumer to render verbatim.
fetchFullPackage reads only error.message and error.code, so on a 5xx it shows the
withheld generic sentence and drops the producer's marked one. That read is
fetchFullPackage's, i.e. #7906's surface and shared by all four of its callers, not
this card's tail — filed separately rather than fixed here.

The fix

  1. Reported through this file's existing posture, not a second one
    formatMetadataError on the shared studio-package-list sonner id. One outage
    rejects both halves of this callback and both report on that id, so it stays one
    toast rather than a stack. ⛔ No new state machine, no new channel.
  2. The sheet closes rather than present the pre-action record as current.
  3. fresh === null is reported too — a successful read whose list no longer contains
    the package was dropped by if (fresh) just as quietly as the catch dropped a
    throw. It now says so with the sentence openManage already uses for exactly that
    fact (engine.studio.pkg.manageMissing).

Why closing, and why it is not "just close it"

PackageDetailSheet is an action surface, and it derives its verb from the record it
was handed: enabled = pkg.enabled !== false && pkg.status !== 'disabled' picks both the
button's label and the endpoint it POSTs (.../${enabled ? 'disable' : 'enable'},
PackagesPage.tsx lines 308 / 500 / 650). Left open over a snapshot known to be
pre-action it does not merely show a stale badge — it re-arms the author with the verb
they just fired
: disable succeeds, the refresh fails, the button still reads "Disable"
and still POSTs /disable.

It remains a degradation, never a throw (objectui#7368's standing ruling — one 503
must not take the Studio down): the editor, the top bar and the package list all stay,
the trigger still works, and reopening the sheet re-runs this same read one click away —
which objectui#7881 taught to report its own outcome. What is deliberately not done is
the card's option 2, marking the record stale so the staleness outlives the toast: that
needs a new prop on PackageDetailSheet, which lives in metadata-admin/PackagesPage.tsx
— outside this card's file surface and a signature change under Clause-②. Raised in the
report rather than taken.

The pkgsErr slot: measured on this arm, and NOT written

#7881 ruled A (do not write it) from the in-file rule that pkgsErr is written exactly
where pkgs is, and openManage never writes pkgs. This callback does write
pkgs, so that ruling could not simply be carried over. Measured instead:

  • pkgs is written at exactly two sites — the mount effect and the head of this
    callback — and pkgsErr is written at those same two sites and nowhere else.
  • The tail writes neither. It writes manage.

So the relevant unit is the arm, not the callback: by the time the tail runs, the
head has already recorded the list's own verdict (a fresh list plus setPkgsErr(null),
or its failure). Writing pkgsErr from the tail would mark the trigger failed over
names the head had just refreshed successfully — objectui#7368's lie pointed the other
way. Verdict A, on this arm's own measurement, for a different reason than #7881's.

Evidence — the ablation, with predictions written first

Predictions were recorded before any leg ran. All three legs matched, in count and in
pin identity.

leg mutation predicted observed
A the source file put back to origin/main (f5d2acc35), pins kept 9 red / 4 green 9 failed / 4 passed
B degenerate "just close the sheet", no reporting 8 red / 5 green 8 failed / 5 passed
C degenerate "always close" (close in a finally), no reporting 11 red / 2 green 11 failed / 2 passed

Leg A's red set was §1.1 §1.2 §1.3 §2.1 §2.2 §3 §4 §5.1 §5.2 with the four §6 controls
green — exactly as predicted. The load-bearing red, verbatim:

FAIL |dom| ... > §1 a 503 SERVICE_UNAVAILABLE envelope on the refresh >
             no longer presents the PRE-ACTION record as current
Error: expect(element).not.toBeInTheDocument()
expected document not to contain element, found div
  data-managed-enabled="true"
  data-managed-id="app.b2r4"
  data-managed-status="active"
  data-testid="pkg-sheet"

That is the defect itself: after a lifecycle action and a 503 on the refresh, the sheet
is still on screen holding the pre-action record (status="active",
enabled="true"), and AssertionError: expected "vi.fn()" to be called at least once
on the sibling pin says nothing was reported at all.

⭐ Legs B and C are what exclude the degenerate fixes, and they fail from opposite
sides:

  • B — closing the sheet without reporting buys pin §1.2 and nothing else: all
    eight reporting pins stay red. A "fix" that merely closes cannot pass this file.
  • C — closing unconditionally additionally kills §6.1 / §6.2 / §6.3, the negative
    controls, because it breaks the successful refresh. §6.4 stays green because a real
    deletion is decided by the head and never reaches the tail.

Together they pin the fix between the two ways of being wrong: report without breaking
the refresh, close without closing always.

The mutation was proved on disk before anything was read, and the restore by state:

leg on-disk blob vs HEAD blob 86d9320d restore
A deba94ef differs back to 86d9320d, git diff HEAD --name-only empty
B 1feb5449 differs back to 86d9320d, empty
C 8cd7eaf3 differs back to 86d9320d, empty

Anchors flipped with it — leg A: manageRefreshFailed 1 to 0, keep the current snapshot 0 to 1; legs B/C: manageRefreshFailed 1 to 0 and their own marker 0 to 1.
An empty or unchanged hash aborted the leg rather than letting it be read as a result.
Leg A's blob deba94ef is the same blob PR #7906 recorded as its HEAD blob, which is
the expected cross-check: leg A restores exactly the state #7906 landed.

The script carried trap ... EXIT INT TERM restoring an absolute path, and every
restore named HEAD explicitly — git checkout ref -- path writes the index too, so a
bare git checkout -- path would have handed the mutation straight back while
git status read clean.

No rebuild leg applies: the pins import ./StudioDesignSurface by relative
specifier, so vitest transforms the .tsx source and no dist/ stands between the
mutation and the run.

Verification — all on the pushed commit e09e9857e, git diff HEAD empty

run result
the 13 new pins Test Files 1 passed (1) · Tests 13 passed (13)
the whole objectui#7368 family (this card + #7881 + #7821 + #7368) Test Files 4 passed (4) · Tests 34 passed (34)
whole studio-design/ directory Test Files 51 passed (51) · Tests 284 passed (284)
the 4 metadata-admin/ suites reading the packages surface / i18n table Test Files 4 passed (4) · Tests 18 passed (18)
pnpm --filter @object-ui/app-shell run type-check exit 0, script name echoed
node scripts/check-changeset-presence.mjs ✅ 2 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s): .changeset/7907-manage-snapshot-refresh-reports.md.
check-changeset-no-major · -fixed · -overwrite ✅ No changeset declares a major bump. · ✅ All workspace packages are in the changeset fixed group. · ✅ No pre-existing changeset was modified or deleted.
check:governed-queue-guard --test (the 3 changed paths) ✅ NOT GOVERNED — 3 path(s) checked against 5 governed surface(s); none matched.
check:governed-queue-guard (--self-test) OK check-governed-queue-guard self-test: 132 cases pass
check:i18n-keys · check:i18n-drift · check:i18n-dead-keys exit 0; the new key is not in the dead-key report (0 hits)
check:vi-mock-specifiers · check:vi-mock-inherit ✅ ... OK both
check:control-bytes ✅ check-control-bytes: OK (scanned 6402 tracked text file(s); skipped 85 binary).
check:unreferenced-sources OK Every shipped source file in every covered package is reachable.

The type-check green is not vacuous: tsc -p tsconfig.test.json --listFiles contains
all three edited/added files, 1 hit each. It also needed the dependency closure built
first (pnpm --filter '@object-ui/app-shell^...' build) — without it the run was 100+
TS2307 Cannot find module @object-ui/..., the stale-dist trap, not a real red.

The gate family was derived by hand from the changed paths against package.json and
.github/workflows/ — this repo has no scripts/pm/dispatch-gates.mjs (that script
lives in objectstack and answers only about its own tree).

Lint — a measured narrowing, not a skip

The repo-wide scan is CI's run. Measured here instead:

  • Population read from eslint itself, not guessed: eslint . over the whole affected
    package linted 1081 files (count from --format json), 0 errors.
  • The 3 changed files on their own: 0 errors, 0 warnings on the new test file and on
    i18n.ts. The 19 warnings on StudioDesignSurface.tsx are all pre-existing — they sit
    at lines 760-4280, and every line this PR adds is in 512-590. Notably
    react-hooks/exhaustive-deps does not flag onManageChanged: locale was added to
    its dependency array with the new tFormat reads.
  • Config invariance for untouched files: eslint.config.js extends
    js.configs.recommended + tseslint.configs.recommended and declares no
    parserOptions.project and no projectService (0 matches) — type-aware linting is not
    enabled, so every rule's verdict is a function of that file's own text plus the shared
    config. A diff confined to three files cannot move any untouched file's verdict.

Boundaries held


Generated by Claude Code

…cord (objectui#7907)

The tail of `onManageChanged` — the managed-snapshot refresh that runs after every
lifecycle action fired from the `PackageDetailSheet` — swallowed a failed
`fetchFullPackage` under a bare `catch {}` commented "keep the current snapshot".
That snapshot is one the action itself had just made stale, so the author disabled a
package, was told nothing, and went on reading `Status: Enabled`.

`PackageDetailSheet` derives its lifecycle verb from the record it holds (`enabled`
picks both the button label and the endpoint it POSTs), so leaving it open over a
snapshot known to be pre-action re-armed the author with the verb they had just
fired. The failure is now reported through this surface's existing objectui#7368
posture — `formatMetadataError` on the shared `studio-package-list` sonner id, so one
outage that rejects both halves of this callback is still one toast — and the sheet
closes rather than present the pre-action record as current. Still a degradation and
never a throw: the editor, the top bar and the package list stay, and no navigation
is inferred from a refresh that could not happen (objectui#7821).

The same tail dropped `fresh === null` — a successful read whose list no longer
contains the package — just as quietly, and now reports it with the sentence
`openManage` already uses.

Not recorded in `pkgsErr`, measured rather than inherited: that slot is written
exactly where `pkgs` is — the mount effect and this callback's HEAD. The tail writes
neither; it writes `manage`. The head has just recorded the list's own verdict, so
writing the slot from here would mark the trigger `failed` over names the head
refreshed successfully a moment ago.

Pre-existing, and objectui#7881 (PR #7906) made it much easier to hit rather than
causing it: before that fix this `catch` could only ever see a `res.json()`
rejection; now that `fetchFullPackage` refuses a non-2xx it also swallowed every
401 / 403 / 503 / 500.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KbJQ1y1J12nZxYzFWhP8Q3
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 50 chunks) 3187.5 KB 3191.4 KB
Main entry chunk (gzip) 143.5 KB 350 KB
Entry file index-C48ndOmR.js
Status PASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 15.67KB 5.75KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 5.13KB 2.35KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 510.60KB 116.20KB
core (index.js) 6.96KB 2.79KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 182.08KB 50.62KB
fields (index.js) 242.44KB 61.25KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 4.28KB 1.75KB
i18n (index.js) 3.65KB 1.47KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.98KB 10.98KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 4.39KB 1.66KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 11.71KB 4.29KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 5.12KB 1.74KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 47.87KB 13.31KB
plugin-charts (index.js) 70.92KB 19.75KB
plugin-chatbot (index.js) 196.19KB 46.37KB
plugin-dashboard (index.js) 132.88KB 34.69KB
plugin-designer (index.js) 212.86KB 43.19KB
plugin-detail (index.js) 250.55KB 64.06KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 132.87KB 32.66KB
plugin-gantt (index.js) 167.26KB 41.00KB
plugin-grid (index.js) 209.29KB 56.78KB
plugin-kanban (index.js) 52.71KB 14.55KB
plugin-list (index.js) 113.56KB 27.70KB
plugin-map (index.js) 20.44KB 6.78KB
plugin-markdown (index.js) 13.93KB 4.81KB
plugin-report (index.js) 43.59KB 11.97KB
plugin-timeline (index.js) 30.40KB 8.76KB
plugin-tree (index.js) 9.20KB 3.19KB
plugin-view (index.js) 85.24KB 20.94KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 81.07KB 26.86KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 5.41KB 2.34KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 4.93KB 2.24KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 10.35KB 3.60KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.74KB 1.41KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

finding(app-shell): onManageChanged's managed-snapshot refresh swallows a failed read, so the open sheet shows a pre-action record as current

2 participants